perf: cut per-frame cost in the websocket receive pipeline (release 0.1.4) - #83
Merged
Conversation
Release 0.1.4. Removes the duplicated machinery on the inbound frame path and the per-frame async wrapper on the heaviest channel, then restores two behaviours that regressed against 0.1.3. Receive pipeline - ReconnectingWebSocket re-boxed every inbound frame into a fresh MessageEvent and pushed it through native EventTarget.dispatchEvent purely to hand it to HyperliquidEventTarget, which then ran its own dispatch. The SDK's consumer now takes a direct frame hook and the public message event is built only when something outside the package is listening: 1.42x per frame, 305 ns saved. - HyperliquidEventTarget stores a lone listener unboxed and promotes to a copy-on-write array on the second registration, so multi-listener dispatch no longer copies into a snapshot buffer and clears it: 2.6x at 8 listeners, 2.2x at 2, 1.18x at 1. - fastAssetCtxs decodes through node:zlib inflateRawSync where the runtime has it, collapsing a per-frame DecompressionStream, writer, reader and four promises into one call, and delivers in the dispatch tick instead of through the promise queue. DecompressionStream stays as the browser/RN fallback. End to end: 7.6x on delta frames, 1.8x on full snapshots. - The dispatcher reuses the subscription id it already built by concatenation as the wire frame instead of stringifying the identical envelope a second time. Measured against the committed baseline: webData3_frame_dispatch_e2e -38%, l2book_dispatch_50_coins -21%, reconnect_resubscribe_burst -16%, subscribe_200_coins -16%. Large frames move least because JSON.parse dominates them, which the SDK cannot touch. Fixes - fastAssetCtxs read e.detail from the recycled event shell inside a queued continuation. Once frames arrived faster than inflate completed, the continuation read a later frame's payload: deltas were silently dropped and others delivered twice, corrupting downstream price state. The payload is now captured synchronously. - Dispatch skipped the liveness check for listeners unsubscribed by an earlier listener mid-dispatch, so a listener could receive a frame after unsubscribing. 0.1.3 matched EventTarget here; a generation counter restores it at one integer compare per listener. - createL1ActionHash is public and took vaultAddress with no runtime validation, so a malformed address silently hashed to different bytes than the caller described and a 32-byte address hashed identically to its 20-byte truncation. The 0x-prefix, length and hex charset are checked again, as they were at 0.1.3. Tests Adds coverage for the hand-rolled listener registry, the frame hook and public message behaviour, arrival order when synchronous and queued decodes interleave, and two perf scenarios for the fastAssetCtxs decode path, which had none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps to 0.1.4. Removes duplicated machinery on the inbound frame path and the per-frame async wrapper on the heaviest channel, then restores two behaviours that regressed against 0.1.3.
Receive pipeline
MessageEventfor the SDK's own consumerfastAssetCtxsdecodes via nativeinflateRawSyncand delivers in the dispatch tickReconnectingWebSocketwas allocating aMessageEventand paying a fullEventTarget.dispatchEventper frame purely to hand it toHyperliquidEventTarget— the exact per-frame allocation cost this SDK removed from_emit, still sitting one layer upstream. The publicmessageevent is still dispatched whenever anything outside the package listens.Measured against the committed baseline:
webData3_frame_dispatch_e2e−38%,l2book_dispatch_50_coins−21%,reconnect_resubscribe_burst−16%,subscribe_200_coins−16%,spotState_frame_dispatch_e2e−12%,ws_request_round_trip−5%,clearinghouseState_frame_dispatch_e2e−3%. Large frames move least becauseJSON.parsedominates them and the SDK can't touch that.DecompressionStreamremains the browser/RN fallback; both decode paths were differentially tested and agree on all 14 cases including 8000-coin multi-chunk payloads and every invalid input.Fixes
fastAssetCtxs.e.detailwas read from the recycled event shell inside a queued continuation. Once frames arrived faster than inflate completed, the continuation read a later frame's payload — deltas silently dropped, others delivered twice, corrupting downstream price state. Reproduced across three arrival patterns before fixing.EventTargethere; the snapshot-buffer rewrite broke it. A generation counter restores it at one integer compare per listener.createL1ActionHashis publicly exported and tookvaultAddresswith no runtime validation — a0x${string}template type constrains neither charset nor length at runtime, and not at all for JS callers. A malformed address silently hashed to different bytes than the caller described, and a 32-byte address hashed identically to its 20-byte truncation. Validation restored as it was at 0.1.3.Tests
1652 pass / 0 fail. New coverage for the listener registry, the frame hook and public
messagebehaviour, arrival order when synchronous and queued decodes interleave, plus two perf scenarios for thefastAssetCtxsdecode path, which previously had none.Reviewer notes
baseline.jsonstill needs re-recording on a quiet machine. The committed one was recorded withdirty: trueand its across-the-board gains are largely session drift — scenarios over untouched code (http_request−24%,parse_l2_book−15%) moved as much as the real wins. Itsrmealso inflated sharply on several scenarios, which widens the local gate's tolerance. It's now also stale against the new suite fingerprint. I did not re-record it: this machine was at load average 10 during the run, which would bake in the opposite bias.🤖 Generated with Claude Code